--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit cadf7f462faf1c03fdeff4960859feb1d8986ef3
Parents : 1ed271d
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-16T01:39:04-05:00
feat(components): refactor App.vue to use AppShellBanners for emergency and WebSocket status notifications; add ConversationPeerHeader and SettingsSectionBlock components for improved UI structure; implement conversation message helpers and settings services for better configuration management
Changes
17 files changed, 1055 insertions(+), 582 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 3d3d0654..c22f2496 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -4,33 +4,14 @@
class="h-screen w-full flex flex-col transition-colors"
:style="shellCanvasStyle"
>
- <!-- emergency banner -->
- <div
- v-if="appInfo?.emergency"
- class="relative z-[100] bg-red-600 text-white px-4 py-2 text-center text-sm font-bold shadow-md animate-pulse"
- >
- <div class="flex items-center justify-center gap-2">
- <MaterialDesignIcon icon-name="alert-decagram" class="size-5" />
- <span>{{ $t("app.emergency_mode_active") }}</span>
- </div>
- </div>
-
- <div
- v-if="showWsDisconnectedBanner"
- class="relative z-[100] bg-red-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-red-800/80"
- role="status"
- aria-live="polite"
- >
- {{ $t("app.backend_disconnected") }} · {{ wsDisconnectedDurationText }}
- </div>
- <div
- v-if="wsReconnectedBanner"
- class="relative z-[100] bg-emerald-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-emerald-800/80 transition-opacity duration-300"
- role="status"
- aria-live="polite"
- >
- {{ $t("app.backend_reconnected") }}
- </div>
+ <AppShellBanners
+ :show-emergency="Boolean(appInfo?.emergency)"
+ :emergency-label="$t('app.emergency_mode_active')"
+ :show-ws-disconnected="showWsDisconnectedBanner"
+ :ws-disconnected-label="`${$t('app.backend_disconnected')} · ${wsDisconnectedDurationText}`"
+ :show-ws-reconnected="wsReconnectedBanner"
+ :ws-reconnected-label="$t('app.backend_reconnected')"
+ />
<RouterView v-if="$route.name === 'auth'" />
@@ -610,6 +591,7 @@ import CommandPalette from "./CommandPalette.vue";
import IntegrityWarningModal from "./IntegrityWarningModal.vue";
import ChangelogModal from "./ChangelogModal.vue";
import TutorialModal from "./TutorialModal.vue";
+import AppShellBanners from "./layout/AppShellBanners.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
import ToneGenerator from "../js/ToneGenerator";
@@ -630,6 +612,7 @@ export default {
IntegrityWarningModal,
ChangelogModal,
TutorialModal,
+ AppShellBanners,
},
setup() {
const vuetifyTheme = useTheme();
diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
new file mode 100644
index 00000000..7ecc8906
--- /dev/null
+++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
@@ -0,0 +1,65 @@
+<template>
+ <div>
+ <div
+ v-if="showEmergency"
+ class="relative z-[100] bg-red-600 text-white px-4 py-2 text-center text-sm font-bold shadow-md animate-pulse"
+ >
+ <div class="flex items-center justify-center gap-2">
+ <MaterialDesignIcon icon-name="alert-decagram" class="size-5" />
+ <span>{{ emergencyLabel }}</span>
+ </div>
+ </div>
+
+ <div
+ v-if="showWsDisconnected"
+ class="relative z-[100] bg-red-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-red-800/80"
+ role="status"
+ aria-live="polite"
+ >
+ {{ wsDisconnectedLabel }}
+ </div>
+ <div
+ v-if="showWsReconnected"
+ class="relative z-[100] bg-emerald-700 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-emerald-800/80 transition-opacity duration-300"
+ role="status"
+ aria-live="polite"
+ >
+ {{ wsReconnectedLabel }}
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+
+export default {
+ name: "AppShellBanners",
+ components: { MaterialDesignIcon },
+ props: {
+ showEmergency: {
+ type: Boolean,
+ default: false,
+ },
+ emergencyLabel: {
+ type: String,
+ default: "",
+ },
+ showWsDisconnected: {
+ type: Boolean,
+ default: false,
+ },
+ wsDisconnectedLabel: {
+ type: String,
+ default: "",
+ },
+ showWsReconnected: {
+ type: Boolean,
+ default: false,
+ },
+ wsReconnectedLabel: {
+ type: String,
+ default: "",
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
new file mode 100644
index 00000000..7d0cfdea
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
@@ -0,0 +1,179 @@
+<template>
+ <div
+ class="relative z-20 flex flex-wrap items-center gap-y-2 px-3 sm:px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
+ >
+ <div class="flex-shrink-0 mr-3">
+ <LxmfUserIcon
+ :custom-image="selectedPeer.contact_image"
+ :icon-name="selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.icon_name : ''"
+ :icon-foreground-colour="
+ selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.foreground_colour : ''
+ "
+ :icon-background-colour="
+ selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.background_colour : ''
+ "
+ icon-class="shrink-0"
+ :icon-style="messageIconStyle"
+ />
+ </div>
+
+ <div class="min-w-0 flex-1">
+ <div class="flex items-center cursor-pointer min-w-0 group" @click="$emit('edit-display-name')">
+ <div
+ v-if="selectedPeer.custom_display_name != null"
+ class="mr-1.5 text-gray-500 dark:text-zinc-400 group-hover:text-gray-700 dark:group-hover:text-zinc-200 transition-colors"
+ :title="$t('messages.custom_display_name')"
+ >
+ <MaterialDesignIcon icon-name="tag-outline" class="size-4" />
+ </div>
+ <div
+ class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[120px] sm:max-w-sm text-base"
+ :title="selectedPeer.custom_display_name ?? selectedPeer.display_name"
+ >
+ {{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
+ </div>
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5 flex items-center gap-2 min-w-0">
+ <div
+ class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
+ :title="selectedPeer.destination_hash"
+ @click="$emit('copy-hash', selectedPeer.destination_hash)"
+ >
+ {{ destinationDisplay }}
+ </div>
+
+ <div
+ v-if="
+ selectedPeerPath ||
+ selectedPeerSignalMetrics?.snr != null ||
+ selectedPeerLxmfStampInfo?.stamp_cost
+ "
+ class="flex items-center gap-2 min-w-0"
+ >
+ <span class="text-gray-300 dark:text-zinc-700 shrink-0">•</span>
+
+ <div class="flex items-center gap-2 truncate">
+ <span
+ v-if="selectedPeerPath"
+ class="flex items-center cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200 shrink-0"
+ title="Path information"
+ @click="$emit('destination-path-click', selectedPeerPath)"
+ >
+ <span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
+ $t("messages.direct")
+ }}</span>
+ <span v-else>{{ $t("messages.hops_away", { count: selectedPeerPath.hops }) }}</span>
+ </span>
+
+ <span v-if="selectedPeerSignalMetrics?.snr != null" class="flex items-center gap-2 shrink-0">
+ <span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
+ <span
+ class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
+ title="Signal quality"
+ @click="$emit('signal-metrics-click', selectedPeerSignalMetrics)"
+ >{{ $t("messages.snr", { snr: selectedPeerSignalMetrics.snr }) }}</span
+ >
+ </span>
+
+ <span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
+ <span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
+ <span
+ class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
+ title="LXMF stamp requirement"
+ @click="$emit('stamp-info-click', selectedPeerLxmfStampInfo)"
+ >{{ $t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost }) }}</span
+ >
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="ml-auto flex items-center gap-0.5 sm:gap-1.5 min-w-0 shrink-0">
+ <ConversationDropDownMenu
+ v-if="selectedPeer"
+ :peer="selectedPeer"
+ :compact="compactPeerActions"
+ :has-failed-messages="hasFailedOrCancelledMessages"
+ @conversation-deleted="$emit('conversation-deleted')"
+ @set-custom-display-name="$emit('edit-display-name')"
+ @popout="$emit('popout')"
+ @retry-failed="$emit('retry-failed')"
+ @open-telemetry-history="$emit('open-telemetry-history')"
+ @start-call="$emit('start-call')"
+ @share-contact="$emit('share-contact')"
+ />
+
+ <IconButton title="Close" class="shrink-0" @click="$emit('close')">
+ <MaterialDesignIcon icon-name="close" class="size-6 sm:size-7" />
+ </IconButton>
+ </div>
+ </div>
+</template>
+
+<script>
+import Utils from "../../js/Utils";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import IconButton from "../IconButton.vue";
+import LxmfUserIcon from "../LxmfUserIcon.vue";
+import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
+
+export default {
+ name: "ConversationPeerHeader",
+ components: {
+ MaterialDesignIcon,
+ IconButton,
+ LxmfUserIcon,
+ ConversationDropDownMenu,
+ },
+ props: {
+ selectedPeer: {
+ type: Object,
+ required: true,
+ },
+ compactPeerActions: {
+ type: Boolean,
+ default: false,
+ },
+ hasFailedOrCancelledMessages: {
+ type: Boolean,
+ default: false,
+ },
+ messageIconStyle: {
+ type: Object,
+ default: () => ({}),
+ },
+ selectedPeerPath: {
+ type: Object,
+ default: null,
+ },
+ selectedPeerSignalMetrics: {
+ type: Object,
+ default: null,
+ },
+ selectedPeerLxmfStampInfo: {
+ type: Object,
+ default: null,
+ },
+ },
+ emits: [
+ "edit-display-name",
+ "copy-hash",
+ "destination-path-click",
+ "signal-metrics-click",
+ "stamp-info-click",
+ "conversation-deleted",
+ "popout",
+ "retry-failed",
+ "open-telemetry-history",
+ "start-call",
+ "share-contact",
+ "close",
+ ],
+ computed: {
+ destinationDisplay() {
+ return Utils.formatDestinationHash(this.selectedPeer?.destination_hash);
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 7a9e3b20..21417695 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -20,130 +20,28 @@
>
</div>
- <!-- header -->
- <div
+ <ConversationPeerHeader
ref="conversationPeerHeader"
- class="relative z-20 flex flex-wrap items-center gap-y-2 px-3 sm:px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
- >
- <!-- peer icon -->
- <div class="flex-shrink-0 mr-3">
- <LxmfUserIcon
- :custom-image="selectedPeer.contact_image"
- :icon-name="selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.icon_name : ''"
- :icon-foreground-colour="
- selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.foreground_colour : ''
- "
- :icon-background-colour="
- selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.background_colour : ''
- "
- icon-class="shrink-0"
- :icon-style="messageIconStyle"
- />
- </div>
-
- <!-- peer info -->
- <div class="min-w-0 flex-1">
- <div class="flex items-center cursor-pointer min-w-0 group" @click="updateCustomDisplayName">
- <div
- v-if="selectedPeer.custom_display_name != null"
- class="mr-1.5 text-gray-500 dark:text-zinc-400 group-hover:text-gray-700 dark:group-hover:text-zinc-200 transition-colors"
- :title="$t('messages.custom_display_name')"
- >
- <MaterialDesignIcon icon-name="tag-outline" class="size-4" />
- </div>
- <div
- class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[120px] sm:max-w-sm text-base"
- :title="selectedPeer.custom_display_name ?? selectedPeer.display_name"
- >
- {{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
- </div>
- </div>
- <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5 flex items-center gap-2 min-w-0">
- <!-- destination hash -->
- <div
- class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
- :title="selectedPeer.destination_hash"
- @click="copyHash(selectedPeer.destination_hash)"
- >
- {{ formatDestinationHash(selectedPeer.destination_hash) }}
- </div>
-
- <div
- v-if="
- selectedPeerPath ||
- selectedPeerSignalMetrics?.snr != null ||
- selectedPeerLxmfStampInfo?.stamp_cost
- "
- class="flex items-center gap-2 min-w-0"
- >
- <span class="text-gray-300 dark:text-zinc-700 shrink-0">•</span>
-
- <div class="flex items-center gap-2 truncate">
- <!-- hops away -->
- <span
- v-if="selectedPeerPath"
- class="flex items-center cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200 shrink-0"
- title="Path information"
- @click="onDestinationPathClick(selectedPeerPath)"
- >
- <span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
- $t("messages.direct")
- }}</span>
- <span v-else>{{ $t("messages.hops_away", { count: selectedPeerPath.hops }) }}</span>
- </span>
-
- <!-- snr -->
- <span
- v-if="selectedPeerSignalMetrics?.snr != null"
- class="flex items-center gap-2 shrink-0"
- >
- <span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
- <span
- class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
- title="Signal quality"
- @click="onSignalMetricsClick(selectedPeerSignalMetrics)"
- >{{ $t("messages.snr", { snr: selectedPeerSignalMetrics.snr }) }}</span
- >
- </span>
-
- <!-- stamp cost -->
- <span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
- <span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
- <span
- class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
- title="LXMF stamp requirement"
- @click="onStampInfoClick(selectedPeerLxmfStampInfo)"
- >{{
- $t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
- }}</span
- >
- </span>
- </div>
- </div>
- </div>
- </div>
-
- <!-- dropdown menu -->
- <div class="ml-auto flex items-center gap-0.5 sm:gap-1.5 min-w-0 shrink-0">
- <ConversationDropDownMenu
- v-if="selectedPeer"
- :peer="selectedPeer"
- :compact="compactPeerActions"
- :has-failed-messages="hasFailedOrCancelledMessages"
- @conversation-deleted="onConversationDeleted"
- @set-custom-display-name="updateCustomDisplayName"
- @popout="openConversationPopout"
- @retry-failed="retryAllFailedOrCancelledMessages"
- @open-telemetry-history="isTelemetryHistoryModalOpen = true"
- @start-call="onStartCall"
- @share-contact="openShareContactModal"
- />
-
- <IconButton title="Close" class="shrink-0" @click="close">
- <MaterialDesignIcon icon-name="close" class="size-6 sm:size-7" />
- </IconButton>
- </div>
- </div>
+ :selected-peer="selectedPeer"
+ :compact-peer-actions="compactPeerActions"
+ :has-failed-or-cancelled-messages="hasFailedOrCancelledMessages"
+ :message-icon-style="messageIconStyle"
+ :selected-peer-path="selectedPeerPath"
+ :selected-peer-signal-metrics="selectedPeerSignalMetrics"
+ :selected-peer-lxmf-stamp-info="selectedPeerLxmfStampInfo"
+ @edit-display-name="updateCustomDisplayName"
+ @copy-hash="copyHash"
+ @destination-path-click="onDestinationPathClick"
+ @signal-metrics-click="onSignalMetricsClick"
+ @stamp-info-click="onStampInfoClick"
+ @conversation-deleted="onConversationDeleted"
+ @popout="openConversationPopout"
+ @retry-failed="retryAllFailedOrCancelledMessages"
+ @open-telemetry-history="isTelemetryHistoryModalOpen = true"
+ @start-call="onStartCall"
+ @share-contact="openShareContactModal"
+ @close="close"
+ />
<!-- Telemetry History Modal -->
<div
@@ -1588,6 +1486,14 @@
<script>
import Utils from "../../js/Utils";
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
+import {
+ isTelemetryOnly as isTelemetryOnlyMessage,
+ hasRenderableContent as messageHasRenderableContent,
+ isImageOnlyMessage as computeIsImageOnlyMessage,
+ collectImageFilesFromDataTransfer as collectImagesFromDataTransfer,
+ extractClipboardImageFiles,
+} from "./conversationMessageHelpers.js";
+import ConversationPeerHeader from "./ConversationPeerHeader.vue";
import ConversationMessageEntry from "./ConversationMessageEntry.vue";
import ConversationMessageListVirtual from "./ConversationMessageListVirtual.vue";
import { displayGroupsOldestFirst, MIN_VIRTUAL_DISPLAY_GROUPS } from "./messageListVirtual.js";
@@ -1604,10 +1510,8 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
-import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
import AddImageButton from "./AddImageButton.vue";
import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
-import IconButton from "../IconButton.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
@@ -1622,12 +1526,11 @@ import "emoji-picker-element";
export default {
name: "ConversationViewer",
components: {
- IconButton,
AddImageButton,
ContextMenuDivider,
ContextMenuItem,
ContextMenuPanel,
- ConversationDropDownMenu,
+ ConversationPeerHeader,
MaterialDesignIcon,
SendMessageButton,
AddAudioButton,
@@ -2205,7 +2108,8 @@ export default {
methods: {
setupPeerHeaderResizeObserver() {
this.teardownPeerHeaderResizeObserver();
- const el = this.$refs.conversationPeerHeader;
+ const root = this.$refs.conversationPeerHeader;
+ const el = root && root.$el ? root.$el : root;
if (!el || typeof ResizeObserver === "undefined") {
return;
}
@@ -4382,32 +4286,13 @@ export default {
});
},
isTelemetryOnly(msg) {
- const hasContent = msg.content && msg.content.trim() !== "";
- const hasAttachments = msg.fields?.image || msg.fields?.audio || msg.fields?.file_attachments;
- const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream;
- const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]);
-
- return !hasContent && !hasAttachments && (hasTelemetry || hasCommands);
+ return isTelemetryOnlyMessage(msg);
},
hasRenderableContent(msg) {
- if (msg.content && msg.content.trim() !== "") return true;
- if (msg.fields?.image) return true;
- if (msg.fields?.audio) return true;
- if (msg.fields?.file_attachments) return true;
- if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return true;
- if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return true;
- return false;
+ return messageHasRenderableContent(msg);
},
isImageOnlyMessage(chatItem) {
- const msg = chatItem.lxmf_message;
- if (!msg.fields?.image) return false;
- if (msg.fields?.audio || msg.fields?.file_attachments) return false;
- const content = (msg.content || "").trim();
- if (content && !this.shouldHideAutoImageCaption(chatItem)) return false;
- if (msg.reply_to_hash) return false;
- if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return false;
- if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return false;
- return true;
+ return computeIsImageOnlyMessage(chatItem, (item) => this.shouldHideAutoImageCaption(item));
},
async toggleTracking() {
if (!this.selectedPeer) return;
@@ -4472,40 +4357,7 @@ export default {
}
},
collectImageFilesFromDataTransfer(dt) {
- if (!dt) {
- return [];
- }
- const out = [];
- const seen = new Set();
- const pushIfImage = (f) => {
- if (!f?.type?.startsWith("image/")) {
- return;
- }
- const k = `${f.name}:${f.size}:${f.lastModified}`;
- if (seen.has(k)) {
- return;
- }
- seen.add(k);
- out.push(f);
- };
- if (dt.files?.length) {
- for (let i = 0; i < dt.files.length; i++) {
- pushIfImage(dt.files[i]);
- }
- if (out.length > 0) {
- return out;
- }
- }
- if (dt.items?.length) {
- for (let i = 0; i < dt.items.length; i++) {
- const item = dt.items[i];
- if (item.kind === "file" && item.type?.startsWith("image/")) {
- const f = item.getAsFile();
- pushIfImage(f);
- }
- }
- }
- return out;
+ return collectImagesFromDataTransfer(dt);
},
attachPastedOrDroppedImageFiles(imageBlobs, idPrefix) {
const t = Date.now();
@@ -4519,20 +4371,7 @@ export default {
});
},
onMessagePaste(event) {
- const cd = event.clipboardData;
- if (!cd?.items?.length) {
- return;
- }
- const imageBlobs = [];
- for (let i = 0; i < cd.items.length; i++) {
- const item = cd.items[i];
- if (item.kind === "file" && item.type.startsWith("image/")) {
- const f = item.getAsFile();
- if (f) {
- imageBlobs.push(f);
- }
- }
- }
+ const imageBlobs = extractClipboardImageFiles(event);
if (imageBlobs.length === 0) {
return;
}
diff --git a/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js b/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js
new file mode 100644
index 00000000..b3690121
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js
@@ -0,0 +1,110 @@
+/**
+ * Pure helpers for LXMF message display predicates and clipboard/drag image extraction.
+ */
+
+/**
+ * @param {object} msg
+ * @returns {boolean}
+ */
+export function isTelemetryOnly(msg) {
+ const hasContent = msg.content && msg.content.trim() !== "";
+ const hasAttachments = msg.fields?.image || msg.fields?.audio || msg.fields?.file_attachments;
+ const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream;
+ const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]);
+
+ return !hasContent && !hasAttachments && !!(hasTelemetry || hasCommands);
+}
+
+/**
+ * @param {object} msg
+ * @returns {boolean}
+ */
+export function hasRenderableContent(msg) {
+ if (msg.content && msg.content.trim() !== "") return true;
+ if (msg.fields?.image) return true;
+ if (msg.fields?.audio) return true;
+ if (msg.fields?.file_attachments) return true;
+ if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return true;
+ if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return true;
+ return false;
+}
+
+/**
+ * @param {object} chatItem
+ * @param {(item: object) => boolean} shouldHideAutoImageCaption
+ * @returns {boolean}
+ */
+export function isImageOnlyMessage(chatItem, shouldHideAutoImageCaption) {
+ const msg = chatItem.lxmf_message;
+ if (!msg.fields?.image) return false;
+ if (msg.fields?.audio || msg.fields?.file_attachments) return false;
+ const content = (msg.content || "").trim();
+ if (content && !shouldHideAutoImageCaption(chatItem)) return false;
+ if (msg.reply_to_hash) return false;
+ if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return false;
+ if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return false;
+ return true;
+}
+
+/**
+ * @param {DataTransfer | null | undefined} dt
+ * @returns {File[]}
+ */
+export function collectImageFilesFromDataTransfer(dt) {
+ if (!dt) {
+ return [];
+ }
+ const out = [];
+ const seen = new Set();
+ const pushIfImage = (f) => {
+ if (!f?.type?.startsWith("image/")) {
+ return;
+ }
+ const k = `${f.name}:${f.size}:${f.lastModified}`;
+ if (seen.has(k)) {
+ return;
+ }
+ seen.add(k);
+ out.push(f);
+ };
+ if (dt.files?.length) {
+ for (let i = 0; i < dt.files.length; i++) {
+ pushIfImage(dt.files[i]);
+ }
+ if (out.length > 0) {
+ return out;
+ }
+ }
+ if (dt.items?.length) {
+ for (let i = 0; i < dt.items.length; i++) {
+ const item = dt.items[i];
+ if (item.kind === "file" && item.type?.startsWith("image/")) {
+ const f = item.getAsFile();
+ pushIfImage(f);
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * @param {ClipboardEvent} event
+ * @returns {File[]}
+ */
+export function extractClipboardImageFiles(event) {
+ const cd = event.clipboardData;
+ if (!cd?.items?.length) {
+ return [];
+ }
+ const imageBlobs = [];
+ for (let i = 0; i < cd.items.length; i++) {
+ const item = cd.items[i];
+ if (item.kind === "file" && item.type.startsWith("image/")) {
+ const f = item.getAsFile();
+ if (f) {
+ imageBlobs.push(f);
+ }
+ }
+ }
+ return imageBlobs;
+}
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 40502ba0..b8e16265 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -145,66 +145,55 @@
v-show="hasSearchResults"
class="columns-1 md:columns-2 xl:columns-2 2xl:columns-3 gap-x-8 gap-y-0"
>
- <!-- Banishment -->
- <section
+ <SettingsSectionBlock
v-show="
matchesSearch('stranger', 'attachments', 'trust', 'block', 'banner', 'unknown', 'contact')
"
- class="settings-section break-inside-avoid"
+ eyebrow="Security"
+ :title="$t('app.stranger_protection')"
+ :description="$t('app.stranger_protection_description')"
+ body-class="space-y-4"
>
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">Security</div>
- <h2>{{ $t("app.stranger_protection") }}</h2>
- <p>{{ $t("app.stranger_protection_description") }}</p>
- </div>
- </header>
- <div class="settings-section__body space-y-4">
- <label class="setting-toggle">
- <Toggle
- id="block-attachments-from-strangers"
- v-model="config.block_attachments_from_strangers"
- @update:model-value="onStrangerAttachmentBlockChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("app.block_stranger_attachments")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("app.block_stranger_attachments_description")
- }}</span>
- </span>
- </label>
- <label class="setting-toggle">
- <Toggle
- id="block-all-from-strangers"
- v-model="config.block_all_from_strangers"
- @update:model-value="onBlockAllFromStrangersChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{ $t("app.block_all_from_strangers") }}</span>
- <span class="setting-toggle__description">{{
- $t("app.block_all_from_strangers_description")
- }}</span>
- </span>
- </label>
- <label class="setting-toggle">
- <Toggle
- id="show-unknown-contact-banner"
- v-model="config.show_unknown_contact_banner"
- @update:model-value="onShowUnknownContactBannerChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("app.show_unknown_contact_banner")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("app.show_unknown_contact_banner_description")
- }}</span>
- </span>
- </label>
- </div>
- </section>
+ <label class="setting-toggle">
+ <Toggle
+ id="block-attachments-from-strangers"
+ v-model="config.block_attachments_from_strangers"
+ @update:model-value="onStrangerAttachmentBlockChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.block_stranger_attachments") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.block_stranger_attachments_description")
+ }}</span>
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ id="block-all-from-strangers"
+ v-model="config.block_all_from_strangers"
+ @update:model-value="onBlockAllFromStrangersChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.block_all_from_strangers") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.block_all_from_strangers_description")
+ }}</span>
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ id="show-unknown-contact-banner"
+ v-model="config.show_unknown_contact_banner"
+ @update:model-value="onShowUnknownContactBannerChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.show_unknown_contact_banner") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.show_unknown_contact_banner_description")
+ }}</span>
+ </span>
+ </label>
+ </SettingsSectionBlock>
<section
v-show="matchesSearch(...sectionKeywords.banishment)"
@@ -2148,11 +2137,24 @@ import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Toggle from "../forms/Toggle.vue";
import ShortcutRecorder from "./ShortcutRecorder.vue";
+import SettingsSectionBlock from "./SettingsSectionBlock.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalState from "../../js/GlobalState";
-import GlobalEmitter from "../../js/GlobalEmitter";
+import {
+ numOrNull,
+ sanitizeColorConfigFields as normalizeConfigColors,
+ fetchMergedConfig,
+ patchServerConfig,
+} from "../../js/settings/settingsConfigService";
+import { applyTransportMode } from "../../js/settings/settingsTransportService";
+import * as maintenanceClient from "../../js/settings/settingsMaintenanceClient";
+import {
+ loadVisualiserDisplayPrefs,
+ persistVisualiserShowDisabled,
+ persistVisualiserShowDiscovered,
+} from "../../js/settings/settingsVisualiserPrefs";
export default {
name: "SettingsPage",
@@ -2161,6 +2163,7 @@ export default {
Toggle,
ShortcutRecorder,
LxmfUserIcon,
+ SettingsSectionBlock,
},
data() {
return {
@@ -2487,38 +2490,17 @@ export default {
},
methods: {
loadVisualiserDisplayPrefsFromStorage() {
- try {
- if (typeof localStorage !== "undefined") {
- this.visualiserShowDisabledInterfaces =
- localStorage.getItem("meshchatx.visualiser.showDisabledInterfaces") === "true";
- this.visualiserShowDiscoveredInterfaces =
- localStorage.getItem("meshchatx.visualiser.showDiscoveredInterfaces") === "true";
- }
- } catch {
- /* localStorage unavailable */
- }
+ const p = loadVisualiserDisplayPrefs();
+ this.visualiserShowDisabledInterfaces = p.showDisabledInterfaces;
+ this.visualiserShowDiscoveredInterfaces = p.showDiscoveredInterfaces;
},
onVisualiserShowDisabledChange(val) {
this.visualiserShowDisabledInterfaces = val;
- try {
- if (typeof localStorage !== "undefined") {
- localStorage.setItem("meshchatx.visualiser.showDisabledInterfaces", val ? "true" : "false");
- }
- } catch {
- /* localStorage unavailable */
- }
- GlobalEmitter.emit("visualiser-display-prefs-changed");
+ persistVisualiserShowDisabled(val);
},
onVisualiserShowDiscoveredChange(val) {
this.visualiserShowDiscoveredInterfaces = val;
- try {
- if (typeof localStorage !== "undefined") {
- localStorage.setItem("meshchatx.visualiser.showDiscoveredInterfaces", val ? "true" : "false");
- }
- } catch {
- /* localStorage unavailable */
- }
- GlobalEmitter.emit("visualiser-display-prefs-changed");
+ persistVisualiserShowDiscovered(val);
},
async getTrustedTelemetryPeers() {
try {
@@ -2584,10 +2566,10 @@ export default {
},
async getConfig() {
try {
- const response = await window.api.get("/api/v1/config");
- if (response?.data?.config) {
- this.config = { ...this.config, ...response.data.config };
- this.sanitizeColorConfigFields();
+ const merged = await fetchMergedConfig(window.api, this.config);
+ if (merged) {
+ this.config = merged;
+ normalizeConfigColors(this.config);
const inbound = Number(this.config.lxmf_inbound_stamp_cost);
if (inbound > 0) {
this.lastRememberedInboundStampCost = Math.min(254, inbound);
@@ -2595,7 +2577,6 @@ export default {
}
this.getKeyboardShortcuts();
} catch (e) {
- // do nothing if failed to load config
console.log(e);
}
},
@@ -2624,9 +2605,9 @@ export default {
},
async updateConfig(config, label = null) {
try {
- const response = await window.api.patch("/api/v1/config", config);
- this.config = response.data.config;
- this.sanitizeColorConfigFields();
+ const newConfig = await patchServerConfig(config, window.api);
+ this.config = newConfig;
+ normalizeConfigColors(this.config);
if (label) {
ToastUtils.success(this.$t("app.setting_auto_saved", { label: this.$t(`app.${label}`) }));
}
@@ -2635,49 +2616,22 @@ export default {
console.log(e);
}
},
- numOrNull(v) {
- if (v === null || v === undefined || v === "") {
- return null;
- }
- const n = Number(v);
- return Number.isFinite(n) ? n : null;
- },
sanitizeColorConfigFields() {
if (!this.config) return;
- const hex6 = (value, fallback) => {
- if (value == null || value === "") {
- return fallback;
- }
- if (typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value.trim())) {
- return value.trim();
- }
- return fallback;
- };
- this.config.banished_color = hex6(this.config.banished_color, "#dc2626");
- this.config.message_outbound_bubble_color = hex6(this.config.message_outbound_bubble_color, "#4f46e5");
- this.config.message_failed_bubble_color = hex6(this.config.message_failed_bubble_color, "#ef4444");
- this.config.message_waiting_bubble_color = hex6(this.config.message_waiting_bubble_color, "#e5e7eb");
- const inbound = this.config.message_inbound_bubble_color;
- if (inbound == null || inbound === "") {
- this.config.message_inbound_bubble_color = null;
- } else if (typeof inbound === "string" && /^#[0-9A-Fa-f]{6}$/.test(inbound.trim())) {
- this.config.message_inbound_bubble_color = inbound.trim();
- } else {
- this.config.message_inbound_bubble_color = null;
- }
+ normalizeConfigColors(this.config);
},
async onAnnounceLimitsChange() {
const c = this.config;
await this.updateConfig(
{
- announce_max_stored_lxmf_delivery: this.numOrNull(c.announce_max_stored_lxmf_delivery),
- announce_max_stored_nomadnetwork_node: this.numOrNull(c.announce_max_stored_nomadnetwork_node),
- announce_max_stored_lxmf_propagation: this.numOrNull(c.announce_max_stored_lxmf_propagation),
- announce_fetch_limit_lxmf_delivery: this.numOrNull(c.announce_fetch_limit_lxmf_delivery),
- announce_fetch_limit_nomadnetwork_node: this.numOrNull(c.announce_fetch_limit_nomadnetwork_node),
- announce_fetch_limit_lxmf_propagation: this.numOrNull(c.announce_fetch_limit_lxmf_propagation),
- announce_search_max_fetch: this.numOrNull(c.announce_search_max_fetch),
- discovered_interfaces_max_return: this.numOrNull(c.discovered_interfaces_max_return),
+ announce_max_stored_lxmf_delivery: numOrNull(c.announce_max_stored_lxmf_delivery),
+ announce_max_stored_nomadnetwork_node: numOrNull(c.announce_max_stored_nomadnetwork_node),
+ announce_max_stored_lxmf_propagation: numOrNull(c.announce_max_stored_lxmf_propagation),
+ announce_fetch_limit_lxmf_delivery: numOrNull(c.announce_fetch_limit_lxmf_delivery),
+ announce_fetch_limit_nomadnetwork_node: numOrNull(c.announce_fetch_limit_nomadnetwork_node),
+ announce_fetch_limit_lxmf_propagation: numOrNull(c.announce_fetch_limit_lxmf_propagation),
+ announce_search_max_fetch: numOrNull(c.announce_search_max_fetch),
+ discovered_interfaces_max_return: numOrNull(c.discovered_interfaces_max_return),
},
"announce_limits"
);
@@ -3210,24 +3164,17 @@ export default {
await this.onIsTransportEnabledChange();
},
async onIsTransportEnabledChange() {
- if (this.config.is_transport_enabled) {
- try {
- const response = await window.api.post("/api/v1/reticulum/enable-transport");
- if (response?.data?.message) {
- ToastUtils.success(response.data.message);
- }
- } catch {
- ToastUtils.error(this.$t("settings.failed_enable_transport"));
- }
- } else {
- try {
- const response = await window.api.post("/api/v1/reticulum/disable-transport");
- if (response?.data?.message) {
- ToastUtils.success(response.data.message);
- }
- } catch {
- ToastUtils.error(this.$t("settings.failed_disable_transport"));
+ try {
+ const response = await applyTransportMode(this.config.is_transport_enabled, window.api);
+ if (response?.data?.message) {
+ ToastUtils.success(response.data.message);
}
+ } catch {
+ ToastUtils.error(
+ this.config.is_transport_enabled
+ ? this.$t("settings.failed_enable_transport")
+ : this.$t("settings.failed_disable_transport")
+ );
}
},
async reloadRns() {
@@ -3237,7 +3184,7 @@ export default {
this.reloadingRns = true;
this.reloadRnsStatusMessage = this.$t("app.reloading_rns");
ToastUtils.loading(this.$t("app.reloading_rns"), 0, "settings-rns-reload");
- const response = await window.api.post("/api/v1/reticulum/reload");
+ const response = await maintenanceClient.reloadReticulum(window.api);
if (response?.data?.message) {
this.reloadRnsStatusMessage = response.data.message;
}
@@ -3251,7 +3198,7 @@ export default {
async clearMessages() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/messages");
+ await maintenanceClient.clearMessages(window.api);
ToastUtils.success(this.$t("maintenance.messages_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
@@ -3260,7 +3207,7 @@ export default {
async clearAnnounces() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/announces");
+ await maintenanceClient.clearAnnounces(window.api);
ToastUtils.success(this.$t("maintenance.announces_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
@@ -3269,9 +3216,7 @@ export default {
async clearNomadnetFavorites() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/favourites", {
- params: { aspect: "nomadnetwork.node" },
- });
+ await maintenanceClient.clearNomadnetFavorites(window.api);
ToastUtils.success(this.$t("maintenance.favourites_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
@@ -3280,7 +3225,7 @@ export default {
async clearLxmfIcons() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/lxmf-icons");
+ await maintenanceClient.clearLxmfIcons(window.api);
ToastUtils.success(this.$t("maintenance.lxmf_icons_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
@@ -3289,7 +3234,7 @@ export default {
async clearStickers() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/stickers");
+ await maintenanceClient.clearStickers(window.api);
ToastUtils.success(this.$t("maintenance.stickers_cleared"));
await this.loadStickerCount();
} catch {
@@ -3297,13 +3242,7 @@ export default {
}
},
async loadStickerCount() {
- try {
- const response = await window.api.get("/api/v1/stickers");
- const list = response.data?.stickers;
- this.stickerCount = Array.isArray(list) ? list.length : 0;
- } catch {
- this.stickerCount = 0;
- }
+ this.stickerCount = await maintenanceClient.fetchStickerCount(window.api);
},
async exportStickers() {
try {
@@ -3353,7 +3292,7 @@ export default {
async clearArchives() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/archives");
+ await maintenanceClient.clearArchives(window.api);
ToastUtils.success(this.$t("maintenance.archives_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
@@ -3362,7 +3301,7 @@ export default {
async clearReticulumDocs() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
- await window.api.delete("/api/v1/maintenance/docs/reticulum");
+ await maintenanceClient.clearReticulumDocs(window.api);
ToastUtils.success(this.$t("maintenance.docs_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
diff --git a/meshchatx/src/frontend/components/settings/SettingsSectionBlock.vue b/meshchatx/src/frontend/components/settings/SettingsSectionBlock.vue
new file mode 100644
index 00000000..4b5ca629
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/SettingsSectionBlock.vue
@@ -0,0 +1,47 @@
+<template>
+ <section class="settings-section break-inside-avoid" :class="sectionClass">
+ <header v-if="hasHeader" class="settings-section__header">
+ <div>
+ <div v-if="eyebrow" class="settings-section__eyebrow">{{ eyebrow }}</div>
+ <h2 v-if="title">{{ title }}</h2>
+ <p v-if="description">{{ description }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body" :class="bodyClass">
+ <slot />
+ </div>
+ </section>
+</template>
+
+<script>
+export default {
+ name: "SettingsSectionBlock",
+ props: {
+ eyebrow: {
+ type: String,
+ default: "",
+ },
+ title: {
+ type: String,
+ default: "",
+ },
+ description: {
+ type: String,
+ default: "",
+ },
+ sectionClass: {
+ type: [String, Array, Object],
+ default: "",
+ },
+ bodyClass: {
+ type: [String, Array, Object],
+ default: "",
+ },
+ },
+ computed: {
+ hasHeader() {
+ return Boolean(this.eyebrow || this.title || this.description);
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/js/frontendModuleMap.js b/meshchatx/src/frontend/js/frontendModuleMap.js
new file mode 100644
index 00000000..9b13323f
--- /dev/null
+++ b/meshchatx/src/frontend/js/frontendModuleMap.js
@@ -0,0 +1,27 @@
+/**
+ * Frontend module map: primary entry points, large feature surfaces, and shared layers.
+ *
+ * Entry: meshchatx/src/frontend/main.js
+ *
+ * Application shell: components/App.vue (routing, WebSocket shell, sidebar, global modals)
+ *
+ * Feature surfaces (orchestration-heavy .vue files):
+ * - components/messages/ConversationViewer.vue
+ * - components/settings/SettingsPage.vue
+ * - components/call/CallPage.vue
+ * - components/map/MapPage.vue
+ *
+ * Shared state and events:
+ * - js/GlobalState.js, js/GlobalEmitter.js
+ * - js/KeyboardShortcuts.js, js/WebSocketConnection.js
+ *
+ * Extracted domain helpers (non-UI):
+ * - js/settings/settingsConfigService.js
+ * - js/settings/settingsTransportService.js
+ * - js/settings/settingsMaintenanceClient.js
+ * - js/settings/settingsVisualiserPrefs.js
+ * - components/messages/conversationMessageHelpers.js
+ * - components/messages/conversationScroll.js
+ */
+
+export const FRONTEND_MODULE_MAP_VERSION = 1;
diff --git a/meshchatx/src/frontend/js/settings/settingsConfigService.js b/meshchatx/src/frontend/js/settings/settingsConfigService.js
new file mode 100644
index 00000000..91c038df
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/settingsConfigService.js
@@ -0,0 +1,65 @@
+/**
+ * Pure helpers and HTTP-backed config load/patch for settings UI.
+ *
+ * @param {unknown} v
+ * @returns {number|null}
+ */
+export function numOrNull(v) {
+ if (v === null || v === undefined || v === "") {
+ return null;
+ }
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * Normalizes hex color fields on a config object in place.
+ *
+ * @param {Record<string, unknown>} config
+ */
+export function sanitizeColorConfigFields(config) {
+ if (!config) return;
+ const hex6 = (value, fallback) => {
+ if (value == null || value === "") {
+ return fallback;
+ }
+ if (typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value.trim())) {
+ return value.trim();
+ }
+ return fallback;
+ };
+ config.banished_color = hex6(config.banished_color, "#dc2626");
+ config.message_outbound_bubble_color = hex6(config.message_outbound_bubble_color, "#4f46e5");
+ config.message_failed_bubble_color = hex6(config.message_failed_bubble_color, "#ef4444");
+ config.message_waiting_bubble_color = hex6(config.message_waiting_bubble_color, "#e5e7eb");
+ const inbound = config.message_inbound_bubble_color;
+ if (inbound == null || inbound === "") {
+ config.message_inbound_bubble_color = null;
+ } else if (typeof inbound === "string" && /^#[0-9A-Fa-f]{6}$/.test(inbound.trim())) {
+ config.message_inbound_bubble_color = inbound.trim();
+ } else {
+ config.message_inbound_bubble_color = null;
+ }
+}
+
+/**
+ * @param {{ get: (path: string) => Promise<{ data?: { config?: object } }> }} api
+ * @returns {Promise<object|null>}
+ */
+export async function fetchMergedConfig(api, baseConfig) {
+ const response = await api.get("/api/v1/config");
+ if (response?.data?.config) {
+ return { ...baseConfig, ...response.data.config };
+ }
+ return null;
+}
+
+/**
+ * @param {object} partial
+ * @param {{ patch: (path: string, data: object) => Promise<{ data: { config: object } }> }} api
+ * @returns {Promise<object>}
+ */
+export async function patchServerConfig(partial, api) {
+ const response = await api.patch("/api/v1/config", partial);
+ return response.data.config;
+}
diff --git a/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js b/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js
new file mode 100644
index 00000000..86b97270
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js
@@ -0,0 +1,75 @@
+/**
+ * Maintenance, stickers, folders, and RNS reload API calls used from settings.
+ */
+
+/**
+ * @param {{ delete: (path: string, config?: object) => Promise<unknown> }} api
+ */
+export async function clearMessages(api) {
+ await api.delete("/api/v1/maintenance/messages");
+}
+
+/**
+ * @param {{ delete: (path: string) => Promise<unknown> }} api
+ */
+export async function clearAnnounces(api) {
+ await api.delete("/api/v1/maintenance/announces");
+}
+
+/**
+ * @param {{ delete: (path: string, config?: object) => Promise<unknown> }} api
+ */
+export async function clearNomadnetFavorites(api) {
+ await api.delete("/api/v1/maintenance/favourites", {
+ params: { aspect: "nomadnetwork.node" },
+ });
+}
+
+/**
+ * @param {{ delete: (path: string) => Promise<unknown> }} api
+ */
+export async function clearLxmfIcons(api) {
+ await api.delete("/api/v1/maintenance/lxmf-icons");
+}
+
+/**
+ * @param {{ delete: (path: string) => Promise<unknown> }} api
+ */
+export async function clearStickers(api) {
+ await api.delete("/api/v1/maintenance/stickers");
+}
+
+/**
+ * @param {{ delete: (path: string) => Promise<unknown> }} api
+ */
+export async function clearArchives(api) {
+ await api.delete("/api/v1/maintenance/archives");
+}
+
+/**
+ * @param {{ delete: (path: string) => Promise<unknown> }} api
+ */
+export async function clearReticulumDocs(api) {
+ await api.delete("/api/v1/maintenance/docs/reticulum");
+}
+
+/**
+ * @param {{ post: (path: string) => Promise<unknown> }} api
+ */
+export async function reloadReticulum(api) {
+ return api.post("/api/v1/reticulum/reload");
+}
+
+/**
+ * @param {{ get: (path: string) => Promise<{ data?: { stickers?: unknown[] } }> }} api
+ * @returns {Promise<number>}
+ */
+export async function fetchStickerCount(api) {
+ try {
+ const response = await api.get("/api/v1/stickers");
+ const list = response.data?.stickers;
+ return Array.isArray(list) ? list.length : 0;
+ } catch {
+ return 0;
+ }
+}
diff --git a/meshchatx/src/frontend/js/settings/settingsTransportService.js b/meshchatx/src/frontend/js/settings/settingsTransportService.js
new file mode 100644
index 00000000..2175920b
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/settingsTransportService.js
@@ -0,0 +1,12 @@
+/**
+ * Reticulum transport mode enable/disable (separate from config PATCH).
+ *
+ * @param {boolean} enabled
+ * @param {{ post: (path: string) => Promise<unknown> }} api
+ */
+export async function applyTransportMode(enabled, api) {
+ if (enabled) {
+ return api.post("/api/v1/reticulum/enable-transport");
+ }
+ return api.post("/api/v1/reticulum/disable-transport");
+}
diff --git a/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js b/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js
new file mode 100644
index 00000000..3357980b
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js
@@ -0,0 +1,52 @@
+import GlobalEmitter from "../GlobalEmitter";
+
+const KEY_DISABLED = "meshchatx.visualiser.showDisabledInterfaces";
+const KEY_DISCOVERED = "meshchatx.visualiser.showDiscoveredInterfaces";
+
+/**
+ * @returns {{ showDisabledInterfaces: boolean, showDiscoveredInterfaces: boolean }}
+ */
+export function loadVisualiserDisplayPrefs() {
+ try {
+ if (typeof localStorage !== "undefined") {
+ return {
+ showDisabledInterfaces: localStorage.getItem(KEY_DISABLED) === "true",
+ showDiscoveredInterfaces: localStorage.getItem(KEY_DISCOVERED) === "true",
+ };
+ }
+ } catch {
+ /* localStorage unavailable */
+ }
+ return {
+ showDisabledInterfaces: false,
+ showDiscoveredInterfaces: false,
+ };
+}
+
+/**
+ * @param {boolean} val
+ */
+export function persistVisualiserShowDisabled(val) {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(KEY_DISABLED, val ? "true" : "false");
+ }
+ } catch {
+ /* ignore */
+ }
+ GlobalEmitter.emit("visualiser-display-prefs-changed");
+}
+
+/**
+ * @param {boolean} val
+ */
+export function persistVisualiserShowDiscovered(val) {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(KEY_DISCOVERED, val ? "true" : "false");
+ }
+ } catch {
+ /* ignore */
+ }
+ GlobalEmitter.emit("visualiser-display-prefs-changed");
+}
diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index f8534379..7bbb3a10 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -5,6 +5,7 @@ import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
+import { buildFullServerConfig, createWindowApi } from "./fixtures/settingsPageTestApi.js";
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
@@ -45,156 +46,6 @@ vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
},
}));
-/**
- * Mirrors meshchat `get_config_dict` keys used by SettingsPage so PATCH merges stay realistic.
- */
-function buildFullServerConfig(overrides = {}) {
- return {
- display_name: "Test User",
- identity_hash: "abc123",
- identity_public_key: "00",
- lxmf_address_hash: "def456",
- telephone_address_hash: null,
- is_transport_enabled: false,
- auto_announce_enabled: false,
- auto_announce_interval_seconds: 0,
- last_announced_at: null,
- theme: "dark",
- language: "en",
- auto_resend_failed_messages_when_announce_received: true,
- allow_auto_resending_failed_messages_with_attachments: false,
- auto_send_failed_messages_to_propagation_node: false,
- show_suggested_community_interfaces: true,
- lxmf_delivery_transfer_limit_in_bytes: 10_000_000,
- lxmf_propagation_transfer_limit_in_bytes: 256_000,
- lxmf_propagation_sync_limit_in_bytes: 10_240_000,
- lxmf_local_propagation_node_enabled: false,
- lxmf_local_propagation_node_address_hash: "localhash",
- lxmf_preferred_propagation_node_destination_hash: "",
- lxmf_preferred_propagation_node_auto_select: false,
- lxmf_preferred_propagation_node_auto_sync_interval_seconds: 3600,
- lxmf_preferred_propagation_node_last_synced_at: null,
- lxmf_user_icon_name: "account",
- lxmf_user_icon_foreground_colour: "#111827",
- lxmf_user_icon_background_colour: "#e5e7eb",
- lxmf_inbound_stamp_cost: 8,
- lxmf_propagation_node_stamp_cost: 16,
- page_archiver_enabled: false,
- page_archiver_max_versions: 5,
- archives_max_storage_gb: 1,
- backup_max_count: 5,
- crawler_enabled: false,
- crawler_max_retries: 3,
- crawler_retry_delay_seconds: 30,
- crawler_max_concurrent: 2,
- auth_enabled: false,
- voicemail_enabled: false,
- voicemail_greeting: "",
- voicemail_auto_answer_delay_seconds: 0,
- voicemail_max_recording_seconds: 120,
- voicemail_tts_speed: 1,
- voicemail_tts_pitch: 1,
- voicemail_tts_voice: "",
- voicemail_tts_word_gap: 0,
- custom_ringtone_enabled: false,
- ringtone_filename: "",
- ringtone_preferred_id: null,
- ringtone_volume: 50,
- map_offline_enabled: false,
- map_mbtiles_dir: "",
- map_tile_cache_enabled: true,
- map_default_lat: "0",
- map_default_lon: "0",
- map_default_zoom: 2,
- map_tile_server_url: "",
- map_nominatim_api_url: "",
- do_not_disturb_enabled: false,
- telephone_allow_calls_from_contacts_only: false,
- telephone_audio_profile_id: null,
- telephone_web_audio_enabled: true,
- telephone_web_audio_allow_fallback: true,
- call_recording_enabled: false,
- block_attachments_from_strangers: true,
- block_all_from_strangers: false,
- show_unknown_contact_banner: true,
- banished_effect_enabled: true,
- banished_text: "BANISHED",
- banished_color: "#dc2626",
- message_font_size: 14,
- message_icon_size: 28,
- ui_transparency: 0,
- ui_glass_enabled: true,
- message_outbound_bubble_color: "#4f46e5",
- message_inbound_bubble_color: null,
- message_failed_bubble_color: "#ef4444",
- message_waiting_bubble_color: "#e5e7eb",
- translator_enabled: false,
- libretranslate_url: "http://localhost:5000",
- desktop_open_calls_in_separate_window: false,
- desktop_hardware_acceleration_enabled: true,
- blackhole_integration_enabled: true,
- announce_max_stored_lxmf_delivery: 1000,
- announce_max_stored_nomadnetwork_node: 1000,
- announce_max_stored_lxmf_propagation: 1000,
- announce_fetch_limit_lxmf_delivery: 500,
- announce_fetch_limit_nomadnetwork_node: 500,
- announce_fetch_limit_lxmf_propagation: 500,
- announce_search_max_fetch: 2000,
- discovered_interfaces_max_return: 500,
- csp_extra_connect_src: "",
- csp_extra_img_src: "",
- csp_extra_frame_src: "",
- csp_extra_script_src: "",
- csp_extra_style_src: "",
- telephone_tone_generator_enabled: true,
- telephone_tone_generator_volume: 50,
- location_source: "browser",
- location_manual_lat: "0.0",
- location_manual_lon: "0.0",
- location_manual_alt: "0.0",
- telemetry_enabled: false,
- nomad_render_markdown_enabled: true,
- nomad_render_html_enabled: true,
- nomad_render_plaintext_enabled: true,
- nomad_default_page_path: "/page/index.mu",
- gitea_base_url: "https://git.quad4.io",
- docs_download_urls: "",
- ...overrides,
- };
-}
-
-function createWindowApi(serverConfigRef) {
- return {
- get: vi.fn().mockImplementation((url) => {
- if (String(url).includes("/api/v1/config")) {
- return Promise.resolve({ data: { config: { ...serverConfigRef.current } } });
- }
- if (String(url).includes("/api/v1/telemetry/trusted-peers")) {
- return Promise.resolve({ data: { trusted_peers: [] } });
- }
- if (String(url).includes("/api/v1/stickers/export")) {
- return Promise.resolve({ data: { stickers: [] } });
- }
- if (String(url).includes("/api/v1/stickers") && !String(url).includes("export")) {
- return Promise.resolve({ data: { stickers: [] } });
- }
- if (String(url).includes("/api/v1/maintenance/messages/export")) {
- return Promise.resolve({ data: { messages: [] } });
- }
- if (String(url).includes("/api/v1/lxmf/folders/export")) {
- return Promise.resolve({ data: { folders: [] } });
- }
- return Promise.resolve({ data: {} });
- }),
- patch: vi.fn().mockImplementation((url, body) => {
- serverConfigRef.current = { ...serverConfigRef.current, ...body };
- return Promise.resolve({ data: { config: { ...serverConfigRef.current } } });
- }),
- post: vi.fn().mockResolvedValue({ data: { message: "ok" } }),
- delete: vi.fn().mockResolvedValue({ data: {} }),
- };
-}
-
async function mountSettingsPage(api, router = { push: vi.fn() }) {
window.api = api;
const wrapper = mount(SettingsPage, {
@@ -204,6 +55,7 @@ async function mountSettingsPage(api, router = { push: vi.fn() }) {
Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
+ SettingsSectionBlock: { template: "<div class='settings-section-block'><slot /></div>" },
},
mocks: {
$t: (key) => key,
diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js
index 9af059ea..356883e8 100644
--- a/tests/frontend/UIComponents.test.js
+++ b/tests/frontend/UIComponents.test.js
@@ -48,52 +48,6 @@ vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
},
}));
-describe("IconButton Component", () => {
- it("renders with slot content", () => {
- const wrapper = mount(IconButton, {
- slots: {
- default: '<span class="test-content">Click me</span>',
- },
- });
- expect(wrapper.find(".test-content").exists()).toBe(true);
- expect(wrapper.text()).toContain("Click me");
- });
-
- it("has correct button type attribute", () => {
- const wrapper = mount(IconButton);
- expect(wrapper.attributes("type")).toBe("button");
- });
-
- it("emits click event when clicked", async () => {
- const wrapper = mount(IconButton);
- await wrapper.trigger("click");
- expect(wrapper.emitted("click")).toBeTruthy();
- });
-
- it("applies disabled state correctly", () => {
- const wrapper = mount(IconButton, {
- attrs: {
- disabled: true,
- },
- });
- expect(wrapper.attributes("disabled")).toBeDefined();
- });
-
- it("applies custom classes", () => {
- const wrapper = mount(IconButton, {
- attrs: {
- class: "custom-class",
- },
- });
- expect(wrapper.classes()).toContain("custom-class");
- });
-
- it("root element is a button", () => {
- const wrapper = mount(IconButton);
- expect(wrapper.element.tagName).toBe("BUTTON");
- });
-});
-
describe("DropDownMenuItem Component", () => {
it("renders slot content", () => {
const wrapper = mount(DropDownMenuItem, {
@@ -424,6 +378,7 @@ describe("SettingsPage Component", () => {
Toggle: Toggle,
ShortcutRecorder: { template: "<div></div>" },
RouterLink: { template: "<a><slot /></a>" },
+ SettingsSectionBlock: { template: '<div class="settings-section-block"><slot /></div>' },
},
mocks: {
$t: (key) => key,
diff --git a/tests/frontend/conversationMessageHelpers.test.js b/tests/frontend/conversationMessageHelpers.test.js
new file mode 100644
index 00000000..64f8738c
--- /dev/null
+++ b/tests/frontend/conversationMessageHelpers.test.js
@@ -0,0 +1,63 @@
+import { describe, it, expect } from "vitest";
+import {
+ isTelemetryOnly,
+ hasRenderableContent,
+ isImageOnlyMessage,
+ collectImageFilesFromDataTransfer,
+ extractClipboardImageFiles,
+} from "../../meshchatx/src/frontend/components/messages/conversationMessageHelpers.js";
+
+describe("conversationMessageHelpers", () => {
+ it("hasRenderableContent detects text and attachments", () => {
+ expect(hasRenderableContent({ content: " hi " })).toBe(true);
+ expect(hasRenderableContent({ content: "", fields: { image: {} } })).toBe(true);
+ expect(hasRenderableContent({ content: " ", fields: {} })).toBe(false);
+ });
+
+ it("isTelemetryOnly when only telemetry fields", () => {
+ expect(
+ isTelemetryOnly({
+ content: "",
+ fields: { telemetry: { x: 1 } },
+ })
+ ).toBe(true);
+ expect(
+ isTelemetryOnly({
+ content: "a",
+ fields: { telemetry: { x: 1 } },
+ })
+ ).toBe(false);
+ });
+
+ it("isImageOnlyMessage respects shouldHideAutoImageCaption", () => {
+ const chatItem = {
+ lxmf_message: {
+ fields: { image: {} },
+ content: "cap",
+ },
+ };
+ expect(isImageOnlyMessage(chatItem, () => true)).toBe(true);
+ expect(isImageOnlyMessage(chatItem, () => false)).toBe(false);
+ });
+
+ it("collectImageFilesFromDataTransfer collects image files", () => {
+ const f = new File(["x"], "a.png", { type: "image/png" });
+ const dt = { files: [f] };
+ const out = collectImageFilesFromDataTransfer(dt);
+ expect(out.length).toBe(1);
+ expect(out[0].type.startsWith("image/")).toBe(true);
+ });
+
+ it("extractClipboardImageFiles reads image items", () => {
+ const f = new File(["x"], "c.png", { type: "image/png" });
+ const item = {
+ kind: "file",
+ type: "image/png",
+ getAsFile: () => f,
+ };
+ const ev = { clipboardData: { items: [item] } };
+ const files = extractClipboardImageFiles(ev);
+ expect(files.length).toBe(1);
+ expect(files[0].name).toBe("c.png");
+ });
+});
diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js
new file mode 100644
index 00000000..0c16d73c
--- /dev/null
+++ b/tests/frontend/fixtures/settingsPageTestApi.js
@@ -0,0 +1,157 @@
+import { vi } from "vitest";
+
+/**
+ * Mirrors meshchat `get_config_dict` keys used by SettingsPage so PATCH merges stay realistic.
+ *
+ * @param {Record<string, unknown>} [overrides]
+ * @returns {Record<string, unknown>}
+ */
+export function buildFullServerConfig(overrides = {}) {
+ return {
+ display_name: "Test User",
+ identity_hash: "abc123",
+ identity_public_key: "00",
+ lxmf_address_hash: "def456",
+ telephone_address_hash: null,
+ is_transport_enabled: false,
+ auto_announce_enabled: false,
+ auto_announce_interval_seconds: 0,
+ last_announced_at: null,
+ theme: "dark",
+ language: "en",
+ auto_resend_failed_messages_when_announce_received: true,
+ allow_auto_resending_failed_messages_with_attachments: false,
+ auto_send_failed_messages_to_propagation_node: false,
+ show_suggested_community_interfaces: true,
+ lxmf_delivery_transfer_limit_in_bytes: 10_000_000,
+ lxmf_propagation_transfer_limit_in_bytes: 256_000,
+ lxmf_propagation_sync_limit_in_bytes: 10_240_000,
+ lxmf_local_propagation_node_enabled: false,
+ lxmf_local_propagation_node_address_hash: "localhash",
+ lxmf_preferred_propagation_node_destination_hash: "",
+ lxmf_preferred_propagation_node_auto_select: false,
+ lxmf_preferred_propagation_node_auto_sync_interval_seconds: 3600,
+ lxmf_preferred_propagation_node_last_synced_at: null,
+ lxmf_user_icon_name: "account",
+ lxmf_user_icon_foreground_colour: "#111827",
+ lxmf_user_icon_background_colour: "#e5e7eb",
+ lxmf_inbound_stamp_cost: 8,
+ lxmf_propagation_node_stamp_cost: 16,
+ page_archiver_enabled: false,
+ page_archiver_max_versions: 5,
+ archives_max_storage_gb: 1,
+ backup_max_count: 5,
+ crawler_enabled: false,
+ crawler_max_retries: 3,
+ crawler_retry_delay_seconds: 30,
+ crawler_max_concurrent: 2,
+ auth_enabled: false,
+ voicemail_enabled: false,
+ voicemail_greeting: "",
+ voicemail_auto_answer_delay_seconds: 0,
+ voicemail_max_recording_seconds: 120,
+ voicemail_tts_speed: 1,
+ voicemail_tts_pitch: 1,
+ voicemail_tts_voice: "",
+ voicemail_tts_word_gap: 0,
+ custom_ringtone_enabled: false,
+ ringtone_filename: "",
+ ringtone_preferred_id: null,
+ ringtone_volume: 50,
+ map_offline_enabled: false,
+ map_mbtiles_dir: "",
+ map_tile_cache_enabled: true,
+ map_default_lat: "0",
+ map_default_lon: "0",
+ map_default_zoom: 2,
+ map_tile_server_url: "",
+ map_nominatim_api_url: "",
+ do_not_disturb_enabled: false,
+ telephone_allow_calls_from_contacts_only: false,
+ telephone_audio_profile_id: null,
+ telephone_web_audio_enabled: true,
+ telephone_web_audio_allow_fallback: true,
+ call_recording_enabled: false,
+ block_attachments_from_strangers: true,
+ block_all_from_strangers: false,
+ show_unknown_contact_banner: true,
+ banished_effect_enabled: true,
+ banished_text: "BANISHED",
+ banished_color: "#dc2626",
+ message_font_size: 14,
+ message_icon_size: 28,
+ ui_transparency: 0,
+ ui_glass_enabled: true,
+ message_outbound_bubble_color: "#4f46e5",
+ message_inbound_bubble_color: null,
+ message_failed_bubble_color: "#ef4444",
+ message_waiting_bubble_color: "#e5e7eb",
+ translator_enabled: false,
+ libretranslate_url: "http://localhost:5000",
+ desktop_open_calls_in_separate_window: false,
+ desktop_hardware_acceleration_enabled: true,
+ blackhole_integration_enabled: true,
+ announce_max_stored_lxmf_delivery: 1000,
+ announce_max_stored_nomadnetwork_node: 1000,
+ announce_max_stored_lxmf_propagation: 1000,
+ announce_fetch_limit_lxmf_delivery: 500,
+ announce_fetch_limit_nomadnetwork_node: 500,
+ announce_fetch_limit_lxmf_propagation: 500,
+ announce_search_max_fetch: 2000,
+ discovered_interfaces_max_return: 500,
+ csp_extra_connect_src: "",
+ csp_extra_img_src: "",
+ csp_extra_frame_src: "",
+ csp_extra_script_src: "",
+ csp_extra_style_src: "",
+ telephone_tone_generator_enabled: true,
+ telephone_tone_generator_volume: 50,
+ location_source: "browser",
+ location_manual_lat: "0.0",
+ location_manual_lon: "0.0",
+ location_manual_alt: "0.0",
+ telemetry_enabled: false,
+ nomad_render_markdown_enabled: true,
+ nomad_render_html_enabled: true,
+ nomad_render_plaintext_enabled: true,
+ nomad_default_page_path: "/page/index.mu",
+ gitea_base_url: "https://git.quad4.io",
+ docs_download_urls: "",
+ ...overrides,
+ };
+}
+
+/**
+ * @param {{ current: Record<string, unknown> }} serverConfigRef
+ */
+export function createWindowApi(serverConfigRef) {
+ return {
+ get: vi.fn().mockImplementation((url) => {
+ if (String(url).includes("/api/v1/config")) {
+ return Promise.resolve({ data: { config: { ...serverConfigRef.current } } });
+ }
+ if (String(url).includes("/api/v1/telemetry/trusted-peers")) {
+ return Promise.resolve({ data: { trusted_peers: [] } });
+ }
+ if (String(url).includes("/api/v1/stickers/export")) {
+ return Promise.resolve({ data: { stickers: [] } });
+ }
+ if (String(url).includes("/api/v1/stickers") && !String(url).includes("export")) {
+ return Promise.resolve({ data: { stickers: [] } });
+ }
+ if (String(url).includes("/api/v1/maintenance/messages/export")) {
+ return Promise.resolve({ data: { messages: [] } });
+ }
+ if (String(url).includes("/api/v1/lxmf/folders/export")) {
+ return Promise.resolve({ data: { folders: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ patch: vi.fn().mockImplementation((url, body) => {
+ serverConfigRef.current = { ...serverConfigRef.current, ...body };
+ return Promise.resolve({ data: { config: { ...serverConfigRef.current } } });
+ }),
+ post: vi.fn().mockResolvedValue({ data: { message: "ok" } }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
+ };
+}
diff --git a/tests/frontend/settingsConfigService.test.js b/tests/frontend/settingsConfigService.test.js
new file mode 100644
index 00000000..ab0a8d94
--- /dev/null
+++ b/tests/frontend/settingsConfigService.test.js
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ numOrNull,
+ sanitizeColorConfigFields,
+ fetchMergedConfig,
+ patchServerConfig,
+} from "../../meshchatx/src/frontend/js/settings/settingsConfigService.js";
+
+describe("settingsConfigService", () => {
+ it("numOrNull returns null for empty input", () => {
+ expect(numOrNull("")).toBeNull();
+ expect(numOrNull(null)).toBeNull();
+ });
+
+ it("numOrNull parses finite numbers", () => {
+ expect(numOrNull("12")).toBe(12);
+ expect(numOrNull(3.5)).toBe(3.5);
+ });
+
+ it("sanitizeColorConfigFields normalizes hex and inbound bubble", () => {
+ const config = {
+ banished_color: "#ff00ff",
+ message_outbound_bubble_color: "bad",
+ message_inbound_bubble_color: "#aabbcc",
+ message_failed_bubble_color: "",
+ message_waiting_bubble_color: "#010203",
+ };
+ sanitizeColorConfigFields(config);
+ expect(config.banished_color).toBe("#ff00ff");
+ expect(config.message_outbound_bubble_color).toBe("#4f46e5");
+ expect(config.message_inbound_bubble_color).toBe("#aabbcc");
+ expect(config.message_failed_bubble_color).toBe("#ef4444");
+ });
+
+ it("fetchMergedConfig merges server config onto base", async () => {
+ const api = {
+ get: vi.fn().mockResolvedValue({
+ data: { config: { theme: "light", display_name: "X" } },
+ }),
+ };
+ const base = { theme: "dark", extra: 1 };
+ const merged = await fetchMergedConfig(api, base);
+ expect(merged).toEqual({ theme: "light", extra: 1, display_name: "X" });
+ });
+
+ it("patchServerConfig returns config from response", async () => {
+ const api = {
+ patch: vi.fn().mockResolvedValue({ data: { config: { theme: "light" } } }),
+ };
+ const out = await patchServerConfig({ theme: "light" }, api);
+ expect(out).toEqual({ theme: "light" });
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────